#======================================================================================
# File: mini2.py
#	This program implements a simple, low performance, single-channel oscilloscope 
#	interface for a Ministrone circuit board. It uses the pyUSB PyQt/PyQwt libraries 
#	to handle the GUI, and it is designed to be portable to many platforms. 
#
# Revisions:
#	12-00-2009 UNK Original program written and published at:
#			http://projectproto.blogspot.com/2009/12/usb-based-oscilloscope-beta.html
#	12-16-2010 JRR Program highly modified and changed from original PIC-USB design to
#				   use AVR based Ministrone board through serial connection instead
#	02-20-2011 JRR Triggering and pre-trigger buffer added
#======================================================================================

import sys
import glob
import serial
import os

import PyQt4.QtCore
import PyQt4.QtGui

import OscopePlot


#======================================================================================

# This is the baud rate for communications through the serial port device. 
OSC_BAUD = 57600

# This is the number of time divisions per plot.
OSC_TIME_DIVS = 5

# The time bases are in a list of lists.  Each list in the list of lists has an index,
# then the time base text, then the time per point in seconds.

OSC_TIMEBASES = [[0, '1 sec',  1.0],
				 [1, '500 ms', 0.5],
				 [1, '200 ms', 0.2],
				 [1, '100 ms', 0.1],
				 [2, '50 ms',  0.05],
				 [2, '20 ms',  0.02],
				 [2, '10 ms',  0.01],
				 [3, '5 ms',   0.005]
				]

# The channels list allows a combo box to be used to select the channel to display.

OSC_CHANNELS = [[0, '0'],
				[1, '1'],
				[2, '2'],
				[3, '3'],
				[4, '4'],
				[5, '5'],
				[6, '6'],
				[7, '7']
			   ]

# This is a list of triggering modes

OSC_TRIG_MODES = [[0, 'Manual'],
				  [1, 'Rising Edge'],
				  [2, 'Falling Edge'],
				  [3, 'Auto']
				 ]


#--------------------------------------------------------------------------------------
# This function scans for serial ports and returns a list of the ports found.  On a
# Linux system, it looks for USB serial ports /dev/ttyUSB*, RS-232 serial ports
# /dev/ttyS*, and Bluetooth serial ports /dev/rfcomm*. 

def scan_for_serial_ports ():
	ports_found = glob.glob ('/dev/ttyUSB*') + glob.glob ('/dev/ttyS*') + \
		glob.glob ('/dev/rfcomm*')
	return (ports_found)


#======================================================================================

class MyForm (PyQt4.QtGui.QDialog):

	#----------------------------------------------------------------------------------

	def __init__ (self, parent = None):
		super (MyForm, self).__init__ (parent)

		# Set overall window parameters
		self.setWindowTitle ("MinistroneScope")
		self.setMinimumSize (640, 400)
		#self.setPreferredSize (640, 480)

		#--------------------- The widgets in the main window -------------------------

		# Text labels for the first row below the scope window
		self.timebase_label = PyQt4.QtGui.QLabel ('Time (sec/div)')
		self.channel_label = PyQt4.QtGui.QLabel ('Channel')
		self.trigger_label = PyQt4.QtGui.QLabel ('Trigger')

		# A combo box which is used to select a time base
		self.timebase_box = PyQt4.QtGui.QComboBox ()
		self.timebase_box.setInsertPolicy (PyQt4.QtGui.QComboBox.NoInsert)
		for index, blah in enumerate (OSC_TIMEBASES):
			self.timebase_box.insertItem (index, OSC_TIMEBASES[index][1])

		# A combo box which is used to select the channel to be displayed
		self.channel_box = PyQt4.QtGui.QComboBox ()
		self.channel_box.setInsertPolicy (PyQt4.QtGui.QComboBox.NoInsert)
		for index, blah in enumerate (OSC_CHANNELS):
			self.channel_box.insertItem (index, OSC_CHANNELS[index][1])

		# This combo box chooses a triggering mode
		self.trigger_mode_box = PyQt4.QtGui.QComboBox ()
		self.trigger_mode_box.setInsertPolicy (PyQt4.QtGui.QComboBox.NoInsert)
		for index, blah in enumerate (OSC_TRIG_MODES):
			self.trigger_mode_box.insertItem (index, OSC_TRIG_MODES[index][1])

		# This combo box chooses a serial port with which to connect
		self.ser_port_list = scan_for_serial_ports ()
		self.serial_port_box = PyQt4.QtGui.QComboBox ()
		self.serial_port_box.setInsertPolicy (PyQt4.QtGui.QComboBox.InsertAtTop)
		self.serial_port_box.insertItem (0, 'Choose Port')
		for index in range (0, len (self.ser_port_list)):
			self.serial_port_box.insertItem (index, self.ser_port_list[index])

		# A button to save data to a file
		self.save_btn = PyQt4.QtGui.QPushButton ('Save')

		# A button to force a measurement now
		self.run_stop_btn = PyQt4.QtGui.QPushButton ('&Run')

		# A button to reset the AVR processor on the Swoop/Ministrone board
		self.reset_avr_btn = PyQt4.QtGui.QPushButton ('Reset')

		# A status label goes at the bottom saying what's happening
		self.status_label = PyQt4.QtGui.QLabel ('Not connected')

		self.update_timer = PyQt4.QtCore.QTimer ()

		# Create the main oscilloscope screen display
		self.scope_display = OscopePlot.OscopePlot ()

		# Time axis: time_per_div, divs_per_plot, pts_per_plot
		self.scope_display.set_time_axis (OSC_TIMEBASES[0][2], OSC_TIME_DIVS, 
										  OscopePlot.OSC_DATA_SET_SIZE)

		# Trigger level input box (the trigger level is typed in)
		self.trig_lev_box = PyQt4.QtGui.QSpinBox ()
		self.trig_lev_box.setMinimum (0)
		self.trig_lev_box.setMaximum (1023)

		#--------------------- The layout of the window -------------------------------
		layout = PyQt4.QtGui.QGridLayout ()
		layout.addWidget (self.scope_display,		0,  0,  1,  5)

		layout.addWidget (self.timebase_label,		2,  0)
		layout.addWidget (self.channel_label,		2,  2)
		layout.addWidget (self.trigger_label,		2,  4)

		layout.addWidget (self.timebase_box,		3,  0)
		layout.addWidget (self.channel_box,			3,  2)
		layout.addWidget (self.trigger_mode_box,	3,  4)

		layout.addWidget (self.serial_port_box,		4,  0)
		layout.addWidget (self.save_btn,			4,  2)
		layout.addWidget (self.run_stop_btn,		4,  4)

		layout.addWidget (self.status_label,		5,  0,  5,  1)

		layout.addWidget (self.reset_avr_btn, 		5,  2)

		layout.addWidget (self.trig_lev_box,		5,  4)		#////////////

		self.setLayout (layout)

		#---------------------------- Set initial values ------------------------------
		# Initially, many of the boxes are grayed out
		self.disableButtons ()				# Gray out most of the buttons
		self.timebase = 0					# Default timebase
		self.channel = 0					# Read channel 0 by default
		self.trigger_mode = 0				# Default trigger mode is manual
		self.ser_port_num = 0				# No serial port chosen yet
		self.serial_port = None				# The serial port handle is empty
		self.running = False				# Not taking and displaying data now
		self.serial_string = ""				# String holds characters from serial port
		self.data_array = []				# The array holding measured data
		self.num_data_got = 0				# Number of data points received so far
		self.num_points = 0					# Temporary counter for points to plot

		#------- Hook up the onscreen controls to their callback functions ------------
		self.connect (self.timebase_box, 
					  PyQt4.QtCore.SIGNAL ("currentIndexChanged(QString)"), 
					  self.set_timebase)
		self.connect (self.channel_box, 
					  PyQt4.QtCore.SIGNAL ("currentIndexChanged(QString)"), 
					  self.set_channel)
		self.connect (self.trigger_mode_box, 
					  PyQt4.QtCore.SIGNAL ("currentIndexChanged(QString)"),
					  self.set_trigger_mode)
		self.connect (self.serial_port_box, 
					  PyQt4.QtCore.SIGNAL ("currentIndexChanged(QString)"),
					  self.set_serial_port)
		self.connect (self.run_stop_btn, 
					  PyQt4.QtCore.SIGNAL ("clicked()"), 
					  self.run_or_stop)  
		self.connect (self.save_btn, 
					  PyQt4.QtCore.SIGNAL ("clicked()"), 
					  self.save_to_file)
		self.connect (self.reset_avr_btn,
					  PyQt4.QtCore.SIGNAL ("clicked()"), 
					  self.reset_avr)
		self.connect (self.channel_box, 
					  PyQt4.QtCore.SIGNAL ("currentIndexChanged()"), 
					  self.set_gain)  
		self.connect (self.update_timer, 
					  PyQt4.QtCore.SIGNAL ("timeout()"), 
					  self.idleProcess)
		self.connect (self.trig_lev_box,
					  PyQt4.QtCore.SIGNAL ("valueChanged (int)"),
					  self.change_trigger_level)

		# The timer has been set up to fire a timeout() whenever there aren't any
		# other events going on. Start it now.
		self.update_timer.start ();


	#----------------------------------------------------------------------------------
	# This method turns off buttons when we don't have a working serial connection to
	# a working Ministrone.

	def disableButtons (self):
		self.timebase_box.setEnabled (False)
		self.channel_box.setEnabled (False)
		self.trigger_mode_box.setEnabled (False)
		self.save_btn.setEnabled (False)
		self.run_stop_btn.setEnabled (False)
		self.trig_lev_box.setEnabled (False)
		self.reset_avr_btn.setEnabled (False)


	#----------------------------------------------------------------------------------
	# This method saves data to a file, if there's data to be saved.
	
	def save_to_file (self):
		# Make sure we're not in automatic mode
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto' or self.running == True:
			self.status_label.setText ('Cannot save while running')
			self.status_label.update ()
			return

		# Make sure there's data to save
		if len (self.data_array) == 0:
			self.status_label.setText ('No data to save')
			self.status_label.update ()
			return

		# Open a file-save dialog
		file_name = PyQt4.QtGui.QFileDialog.getOpenFileName (self, "Save Data", "", 
													 "Data files (*.txt *.csv)")
		if file_name:
			data_file = open (file_name, 'w', 1024)
			zip_dat = zip (self.scope_display.timerange, self.data_array)
			for line in zip_dat:
				data_file.write (str (line[0]))
				data_file.write (',')
				data_file.write (str (line[1]))
				data_file.write ('\n')


	#----------------------------------------------------------------------------------
	# This method chooses a timebase -- how quickly data will be recorded.

	def set_timebase (self):
		tbase_index = self.timebase_box.currentIndex ()
		self.scope_display.set_time_axis (OSC_TIMEBASES[tbase_index][2], OSC_TIME_DIVS,
										  OscopePlot.OSC_DATA_SET_SIZE)
		self.serial_port.write ("t" + str (tbase_index))
		#print ("t" + str (tbase_index))
		return


	#----------------------------------------------------------------------------------
	# This lil' method sets the channel from which data will be taken.

	def set_channel (self):
		if self.channel_box.currentIndex () == -1:
			return

		# Get the channel data at the position in the list of channels
		self.channel = OSC_CHANNELS[self.channel_box.currentIndex ()][0]

		# Write the channel changing command, just a number to the Ministrone
		self.serial_port.write ("c" + str (self.channel))


	#----------------------------------------------------------------------------------
	# This method changes the trigger mode based on what the trigger mode drop-down
	# list specifies. 

	def set_trigger_mode (self, mode_text):  
		self.trigger_mode = OSC_TRIG_MODES[self.trigger_mode_box.currentIndex ()][0]

		# If we've set manual triggering mode, send the DAQ board a 'n' character
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Manual':
			self.serial_port.write ("n")

		# If we've set auto triggering mode, begin running
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto':
			self.start_a_run ()

		# For positive edge triggering, send the DAQ board a '+' character
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Rising Edge':
			self.serial_port.write ("+")

		# For negative edge triggering, send the DAQ board a '-' character
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Falling Edge':
			self.serial_port.write ("-")


	#----------------------------------------------------------------------------------
	# This method is called when somebody changes the trigger level using the spin box.

	def change_trigger_level (self, new_level):
		self.scope_display.set_trigger_level (new_level)
		level_string = str (new_level)
		level_string = level_string.zfill (4)
		level_string = "l" + level_string
		self.serial_port.write (level_string)
		#print level_string


	#----------------------------------------------------------------------------------
	# This method is used to set the serial port to which we're connecting.  It opens
	# (or attempts to open) the port; if successful, the port's ready; if not, an error
	# indication is given.  If the port is successfully opened, the other buttons will
	# be activated. 

	def set_serial_port (self, port_text):

		# Get the port name from the button and update the status message
		port_name = self.ser_port_list[self.serial_port_box.currentIndex ()]
		self.status_label.setText ('Setting Port to: ' + port_name)
		self.status_label.update ()

		# Attempt to open the serial port
		try:
			# Port mode is 8 data bits, 1 stop bit, no parity, timeout = 0.  The zero
			# timeout means return immediately from a read rather than waiting
			self.serial_port = serial.Serial (port_name, OSC_BAUD, serial.EIGHTBITS, 
				serial.PARITY_NONE, serial.STOPBITS_ONE, 0)
		# If there was a problem opening the port, complain
		except serial.SerialException:
			self.serial_port = None
			self.status_label.setText ('Cannot open port ' + port_name)
			self.status_label.update ()
		except ValueError:
			self.status_label.setText ("Serial port parameter out of range")
			self.status_label.update ()
		else:
			# No exception; greet the Ministrone and set it up. Use a timeout for 
			# getting a response to the "v" command
			self.serial_port.timeout = 0.5
			self.serial_port.write ("v")
			init_response = self.serial_port.read (32)
			self.serial_port.timeout = 0

			# If Ministrone doesn't respond, complain; otherwise we're ready to go
			if init_response == '':
				self.status_label.setText ("<b>ERROR:</b> No response from Ministrone")
			else:
				self.status_label.setText ("Connected: " 
					+ init_response.replace (os.linesep, ""))

				# Activate buttons which can be used with an open port
				self.timebase_box.setEnabled (True)
				self.channel_box.setEnabled (True)
				self.trigger_mode_box.setEnabled (True)
				self.run_stop_btn.setEnabled (True)
				self.trig_lev_box.setEnabled (True)
				self.reset_avr_btn.setEnabled (True)

			self.status_label.update ()

		# Put the ministrone in default mode
		#self.set_default_modes ()

		# Ask Ministrone for its current settings
		self.get_current_settings ();

		# Make sure the port list is updated in case somebody plugged in a USB port
		# or turned on a Bluetooth device or something like that
		self.serial_port_list = scan_for_serial_ports ()


	#----------------------------------------------------------------------------------
	# This method responds to a press of the run/stop button, which does different
	# things depending on whether it says 'Run' or 'Stop' at the time it is pressed.

	def run_or_stop (self):
		# If the text isn't "Stop" it should be "Run", so make the 'scope run
		if self.running == False:
			self.start_a_run ()

		# The text was stop, which means this button should cause stopping
		else:
			# If in automatic mode, stop after taking the current trace
			if OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto':
				self.trigger_mode = 0
				self.trigger_mode_box.update ()
				self.status_label.setText ('Auto mode stopped')
				self.status_label.update ()

			# If not in automatic mode, just send a stop command to the Ministrone
			else:
				self.serial_port.write ("s")

			# In any case, make the Run/Stop button say "Run" again
			self.run_stop_btn.setText ("&Run")
			self.run_stop_btn.update ()
			self.running = False


	#----------------------------------------------------------------------------------
	# This method is called when the reset AVR button is pressed. It writes "R" to the
	# port, asking the AVR to reset itself.

	def reset_avr (self):
		self.serial_port.write ("R")


	#----------------------------------------------------------------------------------

	def set_gain (self):
		print "set_gain() not implemented"


	#----------------------------------------------------------------------------------
	# This method sets the Ministrone to default modes on program startup. 

	def set_default_modes (self):
		self.serial_port.write ("c6")		# Set default channel
		self.serial_port.write ("t7")		# Set default timebase


	#----------------------------------------------------------------------------------
	# This method is called when it's time to begin acquiring data for one trace on
	# the 'scope screen. 

	def start_a_run (self):
			# Update the buttons and messages on the GUI
			self.run_stop_btn.setText ("&Stop")
			self.run_stop_btn.update ()
			self.status_label.setText ('Acquiring data')
			self.status_label.update ()
			self.save_btn.setEnabled (False)
			self.save_btn.update ()

			# Clear out the data array so we can save new data
			self.data_array = []

			# Trigger the run if we're in a mode which requires serial port triggering
			if OSC_TRIG_MODES[self.trigger_mode][1] == 'Manual' \
					or OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto':
				self.serial_port.write ("g")

			self.running = True


	#----------------------------------------------------------------------------------
	# This method is called when we finish acquiring data for one trace on the 'scope 
	# screen. 

	def end_a_run (self):
		# Show completion in the status bar
		self.status_label.setText ('Trace complete')
		self.status_label.update ()

		# Reset the trigger button and update the status bar
		#if OSC_TRIG_MODES[self.trigger_mode][1] == 'Manual':
		self.run_stop_btn.setText ("&Run")
		self.running = False

		# Enable the file-save button
		self.save_btn.setEnabled (True)
		self.save_btn.update ()


	#----------------------------------------------------------------------------------
	# This method draws the plot on the screen. 

	def do_a_plot (self):
		tbase_index = self.timebase_box.currentIndex ()
		self.scope_display.set_time_axis (OSC_TIMEBASES[tbase_index][2], 
										  OSC_TIME_DIVS, OscopePlot.OSC_DATA_SET_SIZE)
		self.scope_display.updatePlot (self.data_array)
		self.num_data_got = 0


	#----------------------------------------------------------------------------------
	# This method queries the Ministrone to find out the current timebase, trigger mode
	# and trigger level. It then updates the screen controls to display the correct
	# values.
	def get_current_settings (self):
		# For using the serial port, we'll need to set the timeout to 1 second in case 
		# something goes wrong. The timeout must be reset to 0 when this function has 
		# completed
		self.serial_port.timeout = 1

		# Find the current channel
		self.serial_port.write ("C")
		try:
			inStr = self.serial_port.readline ()
		except serial.serialutil.SerialException:
			self.disableButtons ()
			self.serial_port = None
			return
		chNum = int (inStr)
		if chNum > 7 or chNum < 0:
			print 'Error: Channel cannot be ' + inStr
			return
		self.channel_box.setCurrentIndex (chNum)

		# Find the current timebase
		self.serial_port.write ("T")
		try:
			inStr = self.serial_port.readline ()
		except serial.serialutil.SerialException:
			self.disableButtons ()
			self.serial_port = None
			return
		tBase = int (inStr)
		if tBase > 7:
			print 'Error: Timebase cannot be ' + inStr
			return
		self.timebase_box.setCurrentIndex (tBase)

		# Find the current trigger level
		self.serial_port.write ("L")
		try:
			inStr = self.serial_port.readline ()
		except serial.serialutil.SerialException:
			self.disableButtons ()
			self.serial_port = None
			return
		tLevel = int (inStr)
		if tLevel < 0 or tLevel > 1024:
			print 'Error: Trigger level cannot be ' + inStr
			return
		self.trig_lev_box.setValue (tLevel)
		self.scope_display.set_trigger_level (tLevel)
		if OSC_TRIG_MODES[self.trigger_mode][1] == 'Manual' \
				or OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto':
			self.scope_display.hide_trigger_level ()

		print 'Board says: Timebase ' + str (tBase) + ', Trigger level ' + str (tLevel)

		# Reset the timeout to 0, which exits immediately if no character is present
		self.serial_port.timeout = 0


	#==================================================================================
	# This method is called by the timer whenever there's no other event that needs to
	# run. It grabs data from the serial port if data is available and saves the data
	# in the plot buffer; if it's time to update the plot it does that too. 

	def idleProcess (self):
		# Only try this stuff if the serial port is open
		if self.serial_port != None:

			# Attempt to read a single character from the serial port
			try:
				inChar = self.serial_port.read (1)
			except serial.serialutil.SerialException:
				self.disableButtons ()
				self.serial_port = None
				return

			# If no character has been read, take a little nap
			if inChar == "":
				pass

			# If a character has been successfully read, use it
			else:
				# If we're in a board-triggered mode such as edge triggered and haven't
				# been running, the arrival of a numeric character should mean to start
				# taking data from which to construct a trace
				if OSC_TRIG_MODES[self.trigger_mode][1] == 'Rising Edge' \
						or OSC_TRIG_MODES[self.trigger_mode][1] == 'Falling Edge':
					if inChar.isdigit () and self.running == False:
						self.start_a_run ()

				# If the character is a return, it's at the end of a line which is to
				# be parsed as a number to be concatenated onto the data array, a 
				# header with the number of data points, or a footer with a checksum
				if inChar == '\n':
					# Check if the line's a header
					if self.serial_string[0] == "#":
						try:
							self.num_points = int (self.serial_string.lstrip ("#"))
						except ValueError:
							self.num_points = OscopePlot.OSC_DATA_SET_SIZE
							self.status_label.setText ('Error reading header')
						else:
							self.status_label.setText ("Getting " \
								+ str (self.num_points) + " points")
						self.status_label.update ()

						# If we got a header, start or restart making a graph
						self.checksum = 0
						self.serial_string = ""
						return

					# Check if the line is a footer
					if self.serial_string[0] == "$":
						try:
							self.checksum = int (self.serial_string.lstrip ("$"))
						except ValueError:
							self.checksum = 0
							self.status_label.setText ('Error reading checksum')
						else:
							self.status_label.setText (str (self.num_points) 
										+ " points. Checksum: " + str (self.checksum))
						self.status_label.update ()

						self.serial_string = ""

						# Plot the data and reset all the run parameters
						self.do_a_plot ()
						self.end_a_run ()

						# If we're in Auto triggered mode, run again
						if OSC_TRIG_MODES[self.trigger_mode][1] == 'Auto':
							self.start_a_run ()
						return

					# If the line isn't a header or footer, it should be a data line
					try:
						inData = int (self.serial_string)
					except ValueError:
						inData = -100

					self.num_data_got += 1
					self.serial_string = ""
					self.data_array += [inData]

				# Otherwise, the character should be part of a number which is still 
				# being received
				else:
					self.serial_string += inChar


#======================================================================================

if __name__ == "__main__":  
	app = PyQt4.QtGui.QApplication (sys.argv)  
	form = MyForm ()  
	form.show ()  
	sys.exit (app.exec_())
